Skip to content

feat: gestures and software scroll inversion for MX Vertical - #783

Open
fuloskop wants to merge 5 commits into
AprilNEA:masterfrom
fuloskop:feat/mx-vertical-gestures-and-scroll-inversion
Open

feat: gestures and software scroll inversion for MX Vertical#783
fuloskop wants to merge 5 commits into
AprilNEA:masterfrom
fuloskop:feat/mx-vertical-gestures-and-scroll-inversion

Conversation

@fuloskop

@fuloskop fuloskop commented Aug 21, 2026

Copy link
Copy Markdown

Summary

MX Vertical can use neither gestures nor scroll inversion today, for two unrelated reasons.

Gestures. The device ships no dedicated gesture button, and the capture layer knows only two gesture sources (0x00c3, plus the MX Master 4 haptic panel). diag controls on real hardware:

device: MX Vertical Advanced Ergonomic Mouse (direct 046d:b020)
     cid    task   flags  capabilities
  0x0050  0x0038  0x0411  analytics-events
  0x0051  0x0039  0x0411  analytics-events
  0x0052  0x003a  0x0571  divertable, raw-xy, analytics-events
  0x0053  0x003c  0x0571  divertable, raw-xy, analytics-events
  0x0056  0x003e  0x0571  divertable, raw-xy, analytics-events
  0x00fd  0x00d2  0x0571  divertable, raw-xy, analytics-events
  0x00d7  0x00b4  0x03a0  divertable, raw-xy, force-raw-xy

There is no 0x00c3. 0x00d7 (virtual gesture button) is listed but never emits — armed with raw-XY it produced no events across repeated presses. 0x00fd, the DPI switch, is the control that actually fires, and it is raw-XY capable. Logitech's own asset metadata agrees: for this model it ships SLOT_NAME_GESTURE_{UP,DOWN,LEFT,RIGHT,CLICK}_BUTTON next to SLOT_NAME_DPI_BUTTON, i.e. it also treats the DPI button as the control that gestures here.

The GUI had a second, independent problem: map_slot_name knew only SLOT_NAME_MODESHIFT_BUTTON, so this device's DPI button produced no hotspot and could not be bound at all.

Scroll inversion. Capabilities::scroll_inversion is derived from 0x2121 (HiResWheel). MX Vertical reports 30 features and none is 0x2121, so the toggle read "Unavailable" permanently with no way to reach the setting.

Changes

openlogi-device — the DPI/ModeShift family joins GESTURE_SOURCE_BUTTONS. Also guards the capture session's DPI pass against re-arming a CID already armed with raw-XY: that write carries none and would strip the reporting the hold depends on, reachable exactly on a device whose DPI button is its gesture source.

openlogi-coreButtonId::DpiToggle is now a HID++ gesture source. DPI cycling stays the out-of-the-box behavior: default_binding_for returns Single for that button, and a Single shape drops out of the gesture-map lookup.

openlogi-hook — new EventDisposition::InvertScroll. macOS negates the CGEvent delta fields in place, so the event keeps its phase, momentum and pixel precision and no synthetic replacement re-enters the tap. Only fields the event actually carries are negated, since writing an axis the device left empty would introduce a delta an app reading that field would honour. evdev and WH_MOUSE_LL have no rewrite path yet and pass the disposition through rather than swallowing the scroll.

openlogi-agent-coreHookMaps::invert_scroll carries the identities the hook rewrites, built from the config while excluding natively-capable devices (their setting goes to the firmware; rewriting on top would invert twice). Trackpad scroll is never touched, since macOS already applies its own natural-scrolling preference. Inversion is keyed per device rather than scoped to the selected one, so disabling the selected device empties the button maps without stopping another device's inversion. Two identical directly-attached mice share the one vendor/product pair the hook can see, so when their settings disagree the identity is dropped with a warning rather than applied to both.

openlogi-desktopSLOT_NAME_DPI_BUTTON maps to ButtonId::DpiToggle (the five per-direction gesture markers stay unmapped: same control, and the hotspot builder does not dedupe). The scroll-inversion toggle is offered for pointer-capable devices on macOS, still gated on the native capability elsewhere.

Testing

Commands run on the final tree, all clean:

cargo fmt --all -- --check
cargo clippy -p openlogi-core -p openlogi-device -p openlogi-hid -p openlogi-hook -p openlogi-agent-core -p openlogi-desktop --all-targets -- -D warnings
cargo test -p openlogi-core -p openlogi-device -p openlogi-hid -p openlogi-hook -p openlogi-agent-core -p openlogi-desktop
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agent

646 tests pass. Eight new ones cover: DPI CIDs resolving to ButtonId::DpiToggle, the Single default that keeps DPI cycling, both DPI slot names, the deliberately unmapped direction markers, exclusion of natively-capable and receiver-paired devices, the per-device inversion property, and the identical-device disagreement case.

Hardware verification — runtime-tested on MX Vertical (Bluetooth-direct, macOS):

before after
ten presses of the gesture button 19 × button=DpiToggle action=Cycle DPI Presets, zero gestures gesture_sources=1 dpi_buttons=0; Left/Right/Up/Down and click all dispatch their bound actions
Pointer tab "Invert scroll direction — Unavailable" toggle live; wheel inverts, trackpad unaffected

Not runtime-tested on Linux or Windows — those hook arms are compile-only changes that pass the new disposition through. Not tested with two identical mice attached; that path is covered by unit tests only.

Rebased onto master after the openlogi-device extraction and the hook backend-trait refactor; the only conflict was in macos.rs, where negate_scroll_axis now sits beside usable_scroll_delta and start() stays where the trait refactor put it.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds DPI-button gesture support for MX Vertical and macOS software scroll inversion for devices lacking native firmware support. It also adds conservative VID/PID conflict handling for indistinguishable directly attached mice, although offline twins currently block inversion for an active sibling.

  • Routes DPI/ModeShift controls through HID++ gesture capture while preserving default DPI cycling.
  • Rewrites eligible macOS wheel deltas without affecting trackpads or synthetic event flow.
  • Builds per-device inversion identities and rejects conflicting same-model configurations.

Confidence Score: 4/5

The PR is not yet safe to merge because an offline identical mouse can disable software inversion for the online mouse the user configured.

The same-model conflict fix groups offline inventory entries with active devices, so an absent twin contributes a refusal and removes the online device's VID/PID from the hook inversion set.

Files Needing Attention: crates/openlogi-agent-core/src/orchestrator.rs

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/orchestrator.rs Builds software-inversion identities and resolves same-model conflicts, but an offline twin incorrectly blocks inversion for an online sibling.
crates/openlogi-agent-core/src/hook_runtime.rs Selects software inversion by event VID/PID while excluding trackpads and otherwise preserving existing event handling.
crates/openlogi-hook/src/macos.rs Implements in-place scroll-delta inversion while retaining native event metadata and precision.
crates/openlogi-device/src/session/gesture.rs Adds DPI/ModeShift gesture sources and avoids overwriting an existing raw-XY diversion.
crates/openlogi-core/src/binding/button.rs Classifies the DPI toggle as an HID++ gesture source while retaining its single-action default behavior.
crates/openlogi-desktop/src/state/scroll.rs Exposes software inversion on macOS pointer devices while preserving native-capability gating elsewhere.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Device inventory and config] --> B[Group direct devices by VID/PID]
  B --> C{All present twins agree?}
  C -->|Yes| D[Publish identity in invert_scroll]
  C -->|No| E[Drop shared identity]
  D --> F[macOS hook receives wheel event]
  F --> G{Matching mouse and not trackpad?}
  G -->|Yes| H[Negate carried scroll deltas]
  G -->|No| I[Pass through unchanged]
Loading

Fix all with Greploop Fix All in Codex Fix All in Claude Code

Reviews (5): Last reviewed commit: "Merge branch 'master' into feat/mx-verti..." | Re-trigger Greptile

Comment thread crates/openlogi-agent-core/src/orchestrator.rs Outdated
fuloskop added a commit to fuloskop/OpenLogi that referenced this pull request Aug 22, 2026
The hook matches a scroll event by vendor/product id, which two identical
directly-attached mice share, so one device's inversion setting cannot be
applied without also applying it to its twin. Collapsing them inverted a
wheel the user never configured, untraceable from the GUI.

Group candidates by identity and drop any identity whose devices disagree,
logging a warning instead; identities that agree still yield one entry.
Reported by Greptile on AprilNEA#783.
@davidbudnick davidbudnick added type: feature New feature request platform: all Cross-platform issue labels Aug 22, 2026
@fuloskop
fuloskop force-pushed the feat/mx-vertical-gestures-and-scroll-inversion branch from 0ed3e94 to ac5df67 Compare August 23, 2026 09:20
fuloskop added a commit to fuloskop/OpenLogi that referenced this pull request Aug 23, 2026
The hook matches a scroll event by vendor/product id, which two identical
directly-attached mice share, so one device's inversion setting cannot be
applied without also applying it to its twin. Collapsing them inverted a
wheel the user never configured, untraceable from the GUI.

Group candidates by identity and drop any identity whose devices disagree,
logging a warning instead; identities that agree still yield one entry.
Reported by Greptile on AprilNEA#783.
Comment thread crates/openlogi-agent-core/src/orchestrator.rs Outdated
MX Vertical ships no dedicated gesture button. Its `0x1b04` list carries
no `0x00c3` at all, its `0x00d7` (virtual gesture button) is listed but
never emits an event, and `0x00fd` — the DPI/ModeShift switch — is the
only control there flagged `raw-xy`. That is also the control Logitech's
own software gestures from on this model, corroborated by the device's
asset metadata, which ships `SLOT_NAME_GESTURE_*_BUTTON` markers next to
`SLOT_NAME_DPI_BUTTON`.

Add the DPI/ModeShift family to `GESTURE_SOURCE_BUTTONS` and to
`ButtonId::is_hidpp_gesture_source`, so a gesture map bound to
`ButtonId::DpiToggle` arms a raw-XY divert and dispatches swipes. DPI
cycling remains the out-of-the-box behavior: `default_binding_for` returns
`Single` for that button and a `Single` shape drops out of the gesture-map
lookup, pinned by a test so it cannot regress silently.

Also guard the capture session's DPI pass against re-arming a CID already
armed as a raw-XY gesture source. That write carries no raw-XY and would
strip the reporting the hold depends on — reachable exactly on a device
whose DPI button *is* its gesture source.

Verified on MX Vertical over Bluetooth-direct. Before: ten presses gave 19
`button=DpiToggle action=Cycle DPI Presets` events and no gestures. After:
`gesture_sources=1 dpi_buttons=0`, and all four directions plus click
dispatch their bound actions.
`map_slot_name` knew only `SLOT_NAME_MODESHIFT_BUTTON`, the MX Master
line's name for that control. MX Vertical calls the same button
`SLOT_NAME_DPI_BUTTON`, so it produced no hotspot: the device rendered
with no editable control for its only extra button and could not be bound
from the GUI at all.

The five `SLOT_NAME_GESTURE_*_BUTTON` markers that model also ships stay
unmapped on purpose — they mark the control the DPI marker already covers,
this builder does not dedupe, and the picker renders swipe directions from
the binding's gesture map. Both halves are pinned by tests.
Scroll inversion was gated on HID++ `0x2121` (HiResWheel) reporting an
invert capability, so a device without that feature showed a permanently
"Unavailable" toggle. MX Vertical reports 30 features and none is `0x2121`,
putting the setting out of reach on that hardware.

Add `EventDisposition::InvertScroll`. macOS negates the `CGEvent`'s delta
fields in place, so the event keeps its phase, momentum and pixel
precision and no synthetic replacement re-enters the tap. Only fields the
event actually carries are negated: writing an axis the device left empty
would introduce a delta an app reading that field would honour.

The hook rewrites only devices in `HookMaps::invert_scroll`, which the
orchestrator builds from the config while excluding natively-capable ones —
for those the setting goes to the firmware, and rewriting on top would
invert twice and cancel out. Trackpad scroll is never touched, since macOS
already applies its own natural-scrolling preference there.

Inversion is keyed per device rather than scoped to the selected one, so
disabling the selected device empties the button maps without stopping
another device's inversion. Both properties are covered by tests.

evdev and `WH_MOUSE_LL` have no rewrite path yet; both pass the new
disposition through rather than swallowing the scroll.
The hook matches a scroll event by vendor/product id, which two identical
directly-attached mice share, so it cannot rewrite one without rewriting
the other. Collapsing them applied one device's setting to its twin.

Every reason to skip a device is now a refusal *on its identity* rather
than an omission, and a single refusal drops the identity with a warning:
a disabled device must stay untouched, and a natively-capable one already
carries the setting in firmware, where rewriting on top would invert
twice. An identity whose devices all agree still yields one entry.

Reported by Greptile on AprilNEA#783.
@fuloskop
fuloskop force-pushed the feat/mx-vertical-gestures-and-scroll-inversion branch from ac5df67 to 0e4418c Compare August 23, 2026 18:55
// Per identity: whether a device sharing it wants the rewrite, and
// whether another refuses.
let mut by_identity: BTreeMap<(u32, u32), (bool, bool)> = BTreeMap::new();
for dev in &self.devices {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Offline twin blocks inversion

When two identical directly attached mice remain in inventory while one is offline, this loop still records the offline device as a refusal for their shared VID/PID. The identity is consequently removed from invert_scroll, leaving the online mouse's wheel uninverted despite its enabled software-inversion setting.

Knowledge Base Used: Background agent service

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: all Cross-platform issue type: feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants